Skip to content

0.3.x#20

Open
math3usmartins wants to merge 127 commits into
mainfrom
0.3.x
Open

0.3.x#20
math3usmartins wants to merge 127 commits into
mainfrom
0.3.x

Conversation

@math3usmartins

Copy link
Copy Markdown
Member

No description provided.

math3usmartins and others added 30 commits June 17, 2026 20:42
Introduce the XPHP\Diagnostics namespace: a string-backed Severity enum
(Error/Warning/Notice with isFailing()), a DiagnosticSource enum (xphp/phpstan),
a SourceLocation (file/line/optional column), the immutable Diagnostic, and a
mutable DiagnosticCollector (add/all/hasErrors/count).

Pure value objects with no pipeline wiring yet — the foundation for the
forthcoming `xphp check` command's structured, collect-all diagnostics. Unit
tests cover severity gating, collector ordering/error detection, and defaults
(100% mutation score over the new files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tics sink

Thread an optional DiagnosticCollector through the bound-validation path
(Registry ctor -> recordInstantiation -> validateBounds -> checkBounds). When
absent (xphp compile) violations throw exactly as before, byte-identical; when
present each violation is appended as a Diagnostic -- located at the
instantiation site captured from the AST node in RegistryCollector -- and
recording continues so all violations surface in one run.

The user-facing message now comes from a single shared boundViolationMessage()
builder so the throw text and the diagnostic text can never drift. Tests cover
collect-vs-throw, byte-identical and exact message text, multi-violation
collection, and AST-derived source line (100% mutation score over the diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Compiler::check(): parse, build the type hierarchy, collect definitions,
validate defaults-against-bounds, collect instantiations (bounds + missing type
arguments), and report instantiations of undefined templates -- gathering every
error into a DiagnosticCollector and halting before specialization/emit, so a
partially-invalid registry never reaches the fixed-point loop.

Extends the optional-collector seam to the padding path (missing required type
argument), validateDefaultsAgainstBounds (per-parameter, continue-on-error), and
a new collectUndefinedTemplates pass. Each reused message is built by a single
shared helper so the throw (compile) and diagnostic (check) text stay
byte-identical. The parse loop is factored into parseAll(), reused by compile()
and check(); compile()'s undefined-template throw now routes through the shared
builder.

Duplicate-definition is intentionally not part of the seam: RegistryCollector's
already-recorded guard makes the class-template path unreachable and surfacing it
would change compile-mode semantics -- deferred. Variance and method-level
generic checks remain fail-fast (not yet part of the check pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hase

Move the variance-position check out of the parser into a Registry phase
(validateVariancePositions) over collected definitions, wired into compile()
(fail-fast, byte-identical first-violation throw) and check() (collects every
violation across all definitions, each located at the offending member).
VariancePositionValidator now accumulates violations behind a static
assertPositions facade that throws the first when no collector is given or emits
a diagnostic per violation when one is.

The parser-level variance-position tests move to a dedicated
VariancePositionPhaseTest (compile-mode throws via data provider + check-mode
collect/location), and the check integration suite gains a variance_violation
fixture covering the compile-throw and check-collect paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the inner-variance composition walk out of Registry into a dedicated
InnerVarianceValidator (mirroring VariancePositionValidator): it accumulates
violations behind a static assertComposition facade that throws the first when
no collector is given (compile, byte-identical) or emits a diagnostic per
violation (check), each located at the offending member. Registry's
validateInnerVariance is now a thin delegate.

To avoid double-reporting a direct +T/-T misuse, the position check now returns
which definitions it flagged and the inner-variance pass skips them -- matching
compile-mode, where the position check fails fast before inner-variance runs.
Both passes are wired into Compiler::check(); compile() is unchanged.

Adds inner-variance check fixture + collect-mode, gating, and null-file tests
(100% mutation score over the new validator and the diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the variance-position check before the defaults-vs-bounds check so a class
with both surfaces the variance error first in compile-mode — the order it
surfaced when the check lived in the parser. Merge the stacked docblocks on the
two variance delegate methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a DiagnosticRenderer interface and three implementations for `xphp check`
output: TextRenderer (human-readable blocks), JsonRenderer (a stable documented
JSON contract), and GithubRenderer (Actions workflow-command annotations with
proper escaping). Unit tests pin each format exactly, including the JSON shape
and GitHub escaping (100% mutation score over the renderers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire a CheckCommand (`xphp check <source> [--format=text|json|github]`) into the
console alongside compile, sharing one Compiler. It runs the validate-only pass,
renders diagnostics in the chosen format, and exits 0 (clean) / 1 (errors) /
2 (bad source dir or unknown format).

Compiler::check() now parses each file in its own try/catch: a file that fails
to parse (PHP syntax error or an xphp-specific parse rejection) is reported as a
diagnostic and skipped, so the remaining files are still checked. Tests drive the
command via CommandTester across all formats/exit codes, and a parse_error
fixture proves a valid file's bound violation is still reported alongside two
unparseable files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the file read out of check()'s per-file try so an I/O failure surfaces as
itself rather than being mislabeled xphp.parse_error; only parsing is treated as
a recoverable per-file diagnostic. Clarify the parse-error line comment re nikic's
-1 sentinel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an `xphp check` section to the errors reference (formats, exit codes,
per-file parse resilience, and the stable diagnostic codes for the json/github
formats) and a short pointer from the README quick start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pile`

Spell out the scope consequence: a clean `check` does not guarantee a clean
`compile`, because method/function/closure-level generic checks (and the
specialization-loop guards, by design) are not run by `check` yet. Advise keeping
`compile` in the build pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run GenericMethodCompiler in a new validate-only mode from Compiler::check():
process(emit: false) walks the call sites for their bound/missing-arg checks and
the duplicate-function / $this-capture / static-closure rejections, threading the
optional DiagnosticCollector + source locations through the (already
collector-aware) Registry::checkBounds/padArgsWithDefaults and the in-process
throws, while suppressing the specialize/strip/finalize side-effects. xphp compile
is unchanged (default emit: true, no collector -> byte-identical fail-fast).

This makes `xphp check` a validation-superset of `xphp compile`: a class-level
and a method-level generic error are now both reported in one run. New diagnostic
codes xphp.duplicate_generic_function / xphp.closure_this_capture /
xphp.static_closure; bound + missing-arg reuse the existing codes.

Fixtures + CheckPassIntegrationTest cover each new collected diagnostic (with
file:line), the both-passes-in-one-run guarantee, and byte-identical-compile
guards. Docs updated: check now covers all generic validation; only the
specialization-loop guards (depth cap, hash collision) remain compile-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a closure_static fixture + check-collect and compile-throws tests for the
generic static-closure rejection (xphp.static_closure), matching the symmetry of
the other method-level checks (the collect path was previously untested). Add the
three new method-level codes to the errors-doc table, and correct the
validate-only comments (the discarded per-file AST may carry in-place call-site
rewrites; templates are deep-cloned so nothing shared is mutated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump phpstan.neon from level 7 to level 9 and make src/ clean at it:
- CompileCommand / CheckCommand: narrow getArgument()/getOption() (typed mixed) to
  string via is_string() instead of a blind (string) cast — the inputs are always
  strings (required argument / option with a string default), so behavior is
  unchanged; level 9 just rejects casting mixed.
- Specializer: annotate the ATTR_GENERIC_ARGS array as list<TypeRef> so array_map
  infers the callback's parameter type (level-9 callable-variance check).

Full suite green; src/ clean at level 9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The check command is unit-tested in-process via CommandTester, but nothing
exercised the real binary: its autoload wiring, the process exit code the shell
sees, or the rendered github/json/text output on stdout. The released PHAR was
only smoke-tested with `list`, never `check`.

Add test/smoke/check.sh — a parameterized POSIX script (XPHP_BIN selects the
binary) that runs `check` against the clean and multi_error fixtures and asserts
the 0/1/2 exit contract plus that every renderer emits and json stays
well-formed. Wire it in:
- Makefile: `test/check` target.
- ci-core.yml: a dedicated `xphp check (self-test)` job running `make test/check`
  against bin/xphp.
- release.yml: a post-build step running the same script against dist/xphp.phar,
  so a packaged binary that can't gate fails the release before upload.

No src/ changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the PHPStan integration for `xphp check`. PHPStan can't see
`.xphp` generic sugar, so the gate will compile to a throwaway dir and analyse
the concrete output. This adds the three building blocks, each unit-tested:

- PhpStanLocator: resolve the phpstan binary (explicit path → consumer
  vendor/bin/phpstan → $PATH); null when none found (caller emits a non-fatal
  Warning — a missing optional tool never fails the gate).
- PhpStanConfigResolver: resolve the consumer's config (explicit → auto-detect
  phpstan.neon / .neon.dist / .dist.neon). This is the "one config" that drives
  level/rules/extensions.
- CompiledWorkspace: compile sources into a temp dir (dist/ + cache/Generated/),
  retain the live Registry for back-mapping findings to template declarations,
  and recursively clean up (guarding against following symlinks out of the dir).

Promote symfony/process to a runtime require: the runner ships in the PHAR and
shells out to the consumer's phpstan, but it was only present transitively via a
dev dependency and would be dropped by `composer install --no-dev`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e findings

Second slice of the PHPStan integration. Given a compiled workspace:

- RepresentativeSelector picks one specialization per template (first by sorted
  generated FQN — deterministic), mapping it to its file path and back to the
  template's declaration (file + line) via the live Registry. Body type errors
  are erased to nominal types during specialization, so they're identical across
  a template's instantiations — analysing one representative surfaces the bug
  once instead of N times.
- PhpStanRunner writes an ephemeral neon that `includes:` the consumer's config
  by absolute path (so their relative bootstrapFiles/excludePaths still resolve)
  and adds scanDirectories for symbol resolution, then runs `php <phpstan>
  analyse <representatives>` via Symfony Process. Analyse paths on the CLI
  override the consumer's `paths`; level is inherited (or a default when there's
  no consumer config).
- PhpStanOutputParser turns --error-format=json into findings, and crucially
  treats unparseable output or file-less top-level errors as a FAILED run (the
  caller will Warn) rather than a false clean pass.

Workspace dist/Generated dirs are canonicalized (realpath) so the file paths
PHPStan reports join exactly to the representatives even when the temp root is
reached through a symlink (e.g. macOS /var). Adds the body_type_error fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eck`

Completes the PHPStan integration: when the generic checks pass, `xphp check`
now compiles to a throwaway workspace, runs the consumer's PHPStan over the
representative specializations, and merges the findings into the same report and
exit code — one gate.

- PhpStanResultMapper anchors each finding at the originating template's .xphp
  declaration line, with triggeredBy naming the concrete instantiation. An
  unmatched finding (not expected — only representatives are analysed) is
  surfaced without a location rather than leaking a throwaway temp path.
- StaticAnalysisGate orchestrates locate → compile → select → run → map, cleans
  up the workspace in finally, and turns a missing binary or a failed run into a
  non-failing Warning (never a false clean pass, never exit 2). Generic errors
  short-circuit the pass.
- CheckCommand gains --no-phpstan / --phpstan-bin / --phpstan-config and runs the
  gate only when Phase 1 is clean.
- GithubRenderer folds triggeredBy into the annotation message (annotations have
  no separate field), so PR output shows which instantiation surfaced a body error.

e2e tests pin behaviour against a fixed level-5 config fixture (not the repo's own
phpstan.neon) and skip when no phpstan binary is installed. Infection's per-mutant
timeout is raised to 120s because these tests shell out to a real phpstan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unit

Tag the three tests that shell out to a real phpstan binary with @group phpstan
(they already self-skip when vendor/bin/phpstan is absent). Exclude that group
from the fast default `make test/unit` and add `make test/phpstan` to run it on
its own — mirroring the existing php85 group split.

Verified the suite is green both with phpstan installed (the pass runs) and
without it (the group self-skips); pure unit tests for the mapper, parser, config
builder, locator, and workspace always run regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/errors.md + README: document the PHPStan-over-compiled-output pass —
  one config, the binary/config resolution order, one-representative-per-template,
  the Warning-not-failure semantics, --no-phpstan / --phpstan-bin / --phpstan-config,
  and the new phpstan.* diagnostic codes.
- CHANGELOG: add an Unreleased section for `xphp check` and the PHPStan pass.
- CONTRIBUTING: document the `@group phpstan` self-skip convention and the target.
- ci-core.yml: add a `PHPStan pass` job running the @group phpstan tests
  (composer install provides the binary).
- Makefile: name the target `test/phpstan-pass` to disambiguate from `lint/phpstan`.
- smoke: pass --no-phpstan so the binary exit/render self-test stays deterministic
  and independent of a phpstan install (the PHAR bundles none); the PHPStan path is
  covered in-process by CheckCommandPhpStanTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundations for catching a stray/undeclared type parameter in a generic member —
e.g. `interface Foo<Z> { add(T $x): void; }`, which today compiles to a reference
to a non-existent class `\App\Foo\T` with no diagnostic. Behind a no-op (no reader
yet); a later change consumes these to fail compile and report in check.

- NamespaceContext::isImported — is a bare name's first segment brought in by a
  `use`? (imported names are the escape hatch and never flagged).
- TypeHierarchy::isDeclared — does an FQN name a class/interface/trait declared in
  the scanned sources, or a built-in? (reuses the existing ancestor-map walk).
- XphpSourceParser: tag a bare, single-segment, non-imported class name used inside
  a generic context (template or generic method/closure) with a new
  ATTR_SUSPECT_UNDECLARED_TYPE attribute carrying its resolved FQN. shouldQualify()
  already excludes declared params / scalars / FQ / generic-arg names, so a tagged
  name is exactly "a real type or a stray type parameter" — the validator decides
  which via isDeclared.

A dry-run of the rule over the entire .xphp fixture corpus flagged nothing, so it
has no false positives on existing valid code. Compile byte-identical; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches a stray/typo'd type parameter in a generic class/interface/trait member —
e.g. `interface Foo<Z> { public function add(T $x): void; }` where `T` is not a
declared parameter. Previously `xphp compile` silently emitted a reference to a
non-existent class (`\App\Foo\T`) and `xphp check` reported nothing; now it fails
at compile and is collected by check (even without PHPStan, even when the template
is never instantiated).

UndeclaredTypeParameterValidator (mirrors VariancePositionValidator) walks every
member signature position — properties, constructor-promoted + method params,
returns, union/intersection/nullable, and nested closure/arrow signatures — and
flags a name the parser tagged as suspect (bare, single-segment, non-imported,
inside a generic context) whose resolved FQN names no declared or built-in type.
Wired into Compiler::compile (throw, fail-fast) and ::check (collect-all) before
the defaults check. Code `xphp.undeclared_type`.

Imported (`use`) and fully-qualified names are the escape hatch and are never
flagged; the accepted limitation (a same-namespace class in an unscanned plain
`.php` file) is documented with the remedy. Generic methods declared outside a
generic template are validated separately (follow-up); nested ones are covered here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches a stray/undeclared type parameter in generic methods, free functions,
closures, and arrows declared OUTSIDE a generic template — e.g. a generic method
on a plain class, or `function wrap<A>(C $x)` where `C` is a stray. Like the
class-level check, it fails compile (fail-fast, before specialization strips the
templates) and is collected by check.

UndeclaredTypeParameterValidator gains assertMethodLevel(): it walks the AST for
generic method/function/closure/arrow signatures NOT enclosed by a generic
template (which the member walk already owns — a depth counter avoids reporting
the same node twice) and validates them via the shared checkCallable(). The
diagnostic message now names the context ("method `pick`", "function `wrap`",
"closure", "arrow function", or "template `Foo`").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the undeclared-type check to a type parameter's bound and default — e.g.
`class Box<T: Nonexistent>` or `class Pair<A, B = Nonexistent>`, where the name
is a stray/typo'd reference that previously resolved silently to a non-existent
class. Bounds and defaults are TypeRef trees (not AST type nodes, so they carry
no attribute), so TypeRef gains a `suspectUndeclared` flag the parser sets under
the same rule as the member-hint tag (bare, single-segment, non-imported, inside
a generic context, not a declared param). The validator walks each parameter's
bound (incl. intersection/union operands and generic-arg leaves) and default,
flagging suspect names that resolve to no declared/built-in type; duplicates of
one name (e.g. `<T: Bad = Bad>`) collapse to a single finding.

Fails compile and is collected by check, reusing `xphp.undeclared_type`.
Built-in, imported, fully-qualified, multi-segment, and param-referencing
bounds/defaults are not flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ating

`Box::<int, string>` for a one-parameter `Box` used to silently drop the extra
argument and proceed as `Box<int>`. Registry::padArgsWithDefaults now splits its
fast-return: an over-supplied tuple (`> needed`) reports xphp.too_many_type_arguments
(via the already-threaded collector/source-location, else throws), while an
exact-arity tuple keeps the fast-return and under-arity still pads / reports a
missing argument. Covers class- and method/function-level generics (both route
through padArgsWithDefaults). Returning the over-long tuple lets the downstream
arity guards skip specialization, so no broken code is emitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A scalar bound like `class Box<T: int>` was wrongly reported as
xphp.undeclared_type: the bound path set the suspect flag without the scalar
exclusion the default path already had. Fold the scalar check into the shared
isSuspectUndeclared() so bounds and defaults treat `int`/`string`/`self`/… the
same way, and pin it with a scalar bound in the clean fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the significant, hard-to-reverse design choices behind xphp as a set of
public MADR-format records under docs/adr/ — monomorphization vs type erasure,
the build-time transpiler model, RFC-aligned turbofish syntax, marker interfaces
for instanceof, nominal/erased bound checking, the specialization depth cap, the
`xphp check` gate and its collect-or-throw seam, the PHPStan-over-compiled-output
layer, undeclared-type/arity validation, PHAR distribution, and the engineering
quality bar. Each records the problem, options considered, the choice, and its
trade-offs. Adds an index + template and links them from the docs index and
CONTRIBUTING.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The record claimed an unprovable bound "slips through to a runtime TypeError"
and that PHPStan closes that gap. The bound check actually passes only on a
proven `true`: a `false` is reported as a definite violation, and a `null`
(a type the compiler can't see) is also reported at check/compile time with a
distinct "cannot prove it satisfies the bound" message. Reframe the decision
as conservative rejection of the unknown case — the three-valued result exists
to explain that rejection accurately, not to tolerate it — and clarify that
PHPStan (ADR-0009) handles body value-flow, a separate concern from bound
satisfaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
math3usmartins and others added 30 commits June 25, 2026 06:50
…d of skipping it

A bare (turbofish-less) call that resolved to a method-generic template without
all-default type parameters was silently `return null`-ed — clean `check` and
`compile`, then a runtime "Call to undefined method" fatal, since the class carries
only the mangled specialization, never a plain method.

The instance and static call-rewriters now fall through to
`Registry::padArgsWithDefaults` for a bare call (`$args = []`) instead of
short-circuiting: it pads an all-defaults generic as before, and reports/throws
`xphp.missing_type_argument` (collected in `check`, thrown in `compile`) for one that
can't infer its type argument. A method generic takes no inference, so a bare call to
a non-all-default generic is an error, not a silent skip.

Removes the now-unused `hasAllDefaults` helper (padArgsWithDefaults subsumes it).
Non-generic and all-default bare calls are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare (turbofish-less) call to a generic free function was skipped by a separate
early return (it fires for non-generic calls too, before any template resolution),
so a forgotten turbofish on `pick<T>(...)` emitted `pick('b')` verbatim against a
class/namespace that holds only the mangled `pick_T_<…>` — a runtime "undefined
function" fatal that neither check nor compile caught.

rewriteFuncCall now resolves a bare call's name to a registered generic-function FQN
(mirroring PHP resolution: fully-qualified / `use`-aliased direct, unqualified tries
the current namespace then the global scope) and reports `xphp.missing_type_argument`
via padArgsWithDefaults. functionTemplates holds only generic functions, so a
non-generic bare call resolves to null and is left untouched — no false positives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic closure is tracked at assignment, but a bare `$f('x')` (no turbofish) was
doubly skipped: the func-call rewriter bailed before resolving it, and — worse — the
process() fast-path early-return only kept traversal alive for a variable *turbofish*
call site, so a file whose only generic-closure use was a bare call wasn't traversed
at all (clean check + compile, runtime fatal).

The bare-call branch now also resolves a `$f(...)` against the tracked
currentScopeClosureTemplates and reports `xphp.missing_type_argument` for a
non-all-default generic closure (shared with the free-function path via
resolveBareGenericCall). The anonymous-generic pre-scan now also fires on a generic
closure/arrow template assignment, so a bare call to it is traversed and diagnosed.
A non-generic variable call resolves to nothing and is left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… error

A method/function/closure type argument is not inferred from the call arguments, so
a turbofish-less call to a non-all-default generic is `xphp.missing_type_argument`
(caught by check/compile) rather than a silent runtime fatal. Notes the broadened
scope on the diagnostic in errors.md and adds the rule to turbofish.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ction

From code review of the bare-call diagnostics:

- Scope the closure-template tracking. `currentScopeClosureTemplates` was never reset
  on function/method/closure boundaries, so a generic closure assigned to `$f` in one
  method leaked into a sibling scope where `$f` is an unrelated `callable` parameter —
  a bare `$f(...)` there was wrongly reported. It's now snapshotted/reset/restored
  alongside the other per-scope maps (also fixes a latent leak in the turbofish path).
- Skip first-class callables. `pick(...)` / `$obj->m(...)` creates a Closure rather
  than invoking, so it must not be reported as a missing-turbofish call.
- Report a bare generic free-function/closure call even when all type parameters are
  defaulted. A named generic function/closure has no bare or empty-turbofish form, so
  an all-default bare call previously padded silently and still fataled at runtime; it
  now reports `xphp.missing_type_argument` (a generic method whose params are all
  defaulted may still be called bare — that path is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare (turbofish-less) call to a generic method/function/closure now fails compile
and is collected by check as xphp.missing_type_argument, instead of silently
emitting a call to the stripped mangled member and fataling at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t type

The per-scope closure-template and closure-context maps are pushed and
restored across nested function/closure boundaries, but the snapshot
stack's `@var` shape omitted both keys, so static analysis read the
restore as accessing non-existent offsets. Add the two keys to the
declared shape; no behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tance can't carry it

A covariant upcast `Concrete<Sub>` used as `Interface<Super>` must supply
the interface's erased element method specialized at the supertype arg.
The covariant-edge path inherits that member only when its body lives on
a parent-less class implementing that exact interface. The common
collections shape — a list and a set sharing an abstract base, with a
typed method declared on a sub-interface — violates that, and the upcast
previously hard-failed even though a sound member could be emitted.

When inheritance can't carry the member, emit it directly onto the
upcast-source class with a split substitution: the method's bound type
parameter widens to the supertype argument, while the body's enclosing
class parameter is grounded to the upcast-source's own concrete element.
This is sound because the upcast source's element type is a subtype of
the supertype argument, so reading the instance's own backing state
through the widened parameter is type-safe — the same guarantee the
inheritance path already relies on. Distinct supertype arguments produce
distinct mangled names, so a class upcast to several supertypes gets one
member each with no redeclaration.

A method whose parameters are bounded by different enclosing parameters
isn't the uniformly-bounded shape direct emission can derive a single
member for; rather than silently leave the abstract member unimplemented
(a load fatal), it fails loudly with the upcast diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r is in the return type

Direct emission grounds the body's enclosing class parameter to the
upcast source's own concrete element (a subtype of the supertype the
member is emitted at), while the bounded method parameter widens to the
supertype. That split is sound for body reads, but when the enclosing
parameter also appears in the method's return type the emitted member
returns the widened (supertype) value through a subtype return type — a
runtime TypeError. The inheritance path grounds the whole member at one
argument and handles this shape; direct emission cannot, so it now fails
loudly with the upcast diagnostic instead of emitting a member that
fatals when it runs.

Only the return type is inspected: an enclosing parameter is covariant,
so variance checking already forbids it from any parameter position
before the closer runs, making the return type the sole signature slot it
can occupy. A regression test pins the runtime-fataling shape as a loud
compile-time failure, and a companion test pins the variance ordering
that makes the return-type-only check complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unmodeled traits

Record the direct-emission path for a covariant upcast whose member can't
be inherited: the changelog and type-bounds guide now describe emitting
the member onto the upcast source (parameter widened to the supertype,
body read at the source's element type), the residual shapes that still
hard-fail (no class body, an element-typed return, non-uniform bounds),
and the body-reads-at-the-source-element semantics. Update the
`xphp.unschedulable_covariant_upcast` reference to match.

Add ADR-0019 recording that trait-imported members are deliberately not
modeled in the type hierarchy — a trait-only body is a loud residual
(`xphp.unschedulable_covariant_upcast`) rather than a silently missed
member, consistent with the existing variance/bound trait-`use` boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…deling heavyweight

ADR-0019's "fully model traits" option now carries the two concrete shapes
that make partial modeling unsound — `as` aliasing (the synthesized member
must be found under the trait's original name and emitted under the alias)
and `insteadof` conflict resolution (only one of two same-named trait
bodies is authoritative) — so the option reads as the full-PHP-semantics
feature it is, not a shortcut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic type-argument

A covariant upcast whose type-argument is itself a generic of a different
but provably-related template — `Couple<ImmutableList<Book>, X>` viewed as
`Tuple<Collection<Product>, X>` — was silently not emitted: the per-argument
subtype check `isNestedSubtype` resolved both-non-generic and same-template
pairs but returned a conservative false for a different-template generic
pair, even when one template provably implements the other. So the variance
edge was omitted, `xphp check` passed, and the upcast fatal'd at runtime.

Add a cross-template branch: when the two args are generics of different
templates and the child's template provably implements/extends the parent's,
thread the child's args up to the parent's template (the same hierarchy
helper the closer uses) and recurse under the parent's own variance — so
`ImmutableList<Book>` resolves to `Collection<Book>`, then `Book` is compared
against `Product` under `Collection`'s covariant element. Emit only on a
positive subtype result; the recursion's arity guard rejects a malformed
(non-null, wrong-arity) grounding, so a bare or over-supplied parameterized
super never produces a bogus edge. The flip in `isVarianceSubtype` routes
contravariant slots through the same branch, so the case composes
symmetrically across variance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record that a covariant slot whose argument is itself a generic of a
different but related template now emits its variance edge — the variance
guide gains a "nested type-arguments" subsection (a covariant Tuple holding
a covariant container relates by the container's element type, proven by
threading the argument up its implements/extends chain), and the changelog
notes the covariance now holds at runtime rather than passing check and
fataling at load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the inner pass

A method parameter typed `Comparator<E>` on a covariant `+E` class was
wrongly rejected (`xphp.variance_position`) though it is sound: `Comparator`
is contravariant, so `E` sits in a contravariant slot inside a
contravariant parameter position — contra ∘ contra = covariant, which a
covariant `+E` may occupy. The position validator judged the nested `E` by
the direct parameter position without composing the referenced type's slot
variance, and because it flagged the template the composing inner-variance
pass was skipped for it, so the wrong verdict was final.

Reconcile the two passes into disjoint responsibilities: the position
validator now checks only DIRECT occurrences of a variant type-param (a
bare `E` as a parameter/return/property/bound/default), on both its
descent paths; the composing inner-variance pass owns every
type-constructor-nested occurrence and reports only nested leaves, with one
exception — a non-bare direct type-param in a constructor parameter, which
the position pass exempts entirely, stays owned by the composing pass. The
skip handoff is removed, so a template carrying both a direct and a nested
violation now reports each exactly once instead of dropping the nested one.

The composing pass already produced the correct verdicts, so the genuinely
unsound shapes stay rejected: a bare `+E` parameter, a `Producer<E>`
parameter on `+E` (compose to contravariant), and the mirror `Sink<-E>`
with a `Comparator<E>` parameter (compose to covariant). A runtime fixture
proves the now-accepted `Comparator<E>`-on-`+E` shape loads and runs
soundly under a covariant upcast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… property

The composing variance pass kept ownership of the direct leaf of every
constructor parameter, to cover the non-bare shapes (`?T`) the position
pass exempts. But a VISIBLE promoted constructor property (`public T
$item`) is NOT exempt from the position pass — it reports it as a
constructor-parameter violation — so both passes flagged it: in check-mode
the same property produced two diagnostics for one source location.

Cede the direct leaf to the position pass for a promoted constructor
property (non-zero param flags); keep composing-pass ownership only for a
non-promoted constructor param, which the position pass genuinely exempts.
Nested leaves in a promoted property (`public Box<T> $item`) stay owned by
the composing pass. Compile-mode is unaffected (the position pass throws
first); this only removed the duplicate check-mode diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record that `pick(Comparator<E> $c)` on a covariant `Box<+E>` (where
Comparator is contravariant) is sound and now accepted — the variance
guide's composition section gains the contra ∘ contra = covariant case
(the element-consuming counterpart to the covariant immutable
constructor), and the changelog notes nested type-parameters now route
through the composing variance check instead of the bare outer position.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stic

When the specialization fixed-point exceeds the depth cap, report the type
family whose arguments nest the deepest — the tip of the growing tower —
instead of dumping the whole registry. The message names the offending
template and a representative spec, and explains the common cause (a member
whose type re-wraps the receiver's own type family in a growing form). This
turns an opaque whole-registry abort into an actionable, localized error; it
does not change which programs compile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A covariant collection whose grouping derivation re-exposes its own type
family (e.g. groupBy(): Map<L, List<E>> with a Map view that re-exposes the
list) drives an unbounded specialization tower; today it terminates only by
the hard depth cap aborting the build, so a legitimate group-then-iterate
idiom does not compile. A related single-inheritance case compiles but fatals
at class load on an incompatible covariant override.

Record the proposed cure: an opt-in erased seam at an output position that
compiles to the nearest non-generic supertype with the type argument dropped,
cutting the specialization edge there. It mirrors a dynamic-dispatch boundary
in monomorphizing languages and the per-position erasure libraries already do
by hand. Framed as a local, opt-in exception to monomorphization, not a
reversal of it, and complementary to the depth cap (the involuntary backstop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pin how a covariant collection's grouping derivation fails today, so a
regression that degrades the controlled failure into a hang, an OOM, or a
silently-wrong build is caught:

- groupBy then iterate via the map's covariant values()/entries() view towers
  on an unbounded List -> Map -> List -> ... family and aborts fast with the
  localized depth diagnostic naming the runaway type (RuntimeException,
  in-process).
- groupBy across subtype-related element types (Book <: Media) compiles
  cleanly but fatals at PHP class-load on the incompatible covariant override
  (the covariant leaf edge is dropped under single inheritance). The load
  fatal is non-catchable, so it is observed from a child process.

These are accept-when-fixed markers: the tower tests flip to "compiles" once
an erased seam breaks the cycle at the view boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The erased seam is the established escape hatch in monomorphizing languages,
not an xphp invention. Back the claim with primary references and a worked
sample: Rust's trait objects (`dyn Trait` / `Box<dyn Trait>`) are an "erased
type" that enables polymorphism without monomorphization and is the documented
fix for a self-reintroducing generic that otherwise hits the monomorphization
recursion limit. Note that `impl Trait` is opaque-but-monomorphized and is not
the seam — only `dyn` erases. Add the whole-program-erasure contrast (JVM/HHVM)
with a reference, and drop a stray trailing code fence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zation

Supersede the prior "build an erased seam" proposal. A capped experiment
showed the tower is driven by the re-exposing member's BODY (constructing the
strictly-larger value), not its declared return type: changing only the type
still towers, while restructuring the body to return a non-generic iterable
converges. So the author can break the cycle today with no new feature, and a
compiler-side seam would have to reach into the body, not just the signature.

Accepted decision: keep the terminating depth cap, improve the diagnostic
(fire in check, name the offending member), and document the restructuring as
the cure. Defer the dyn-style erased seam as a future ergonomic option, with
the body-erasure requirement and Rust trait-object prior art recorded for if
it is ever built. Rename the ADR and align the boundary test docblock: the
controlled abort is the accepted behavior, not an accept-when-fixed marker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pecialization diverges

The unconverged-specialization diagnostic previously named only the single
deepest type family. When a self-reintroducing tower runs through more than one
class (a list grouping into a map whose view re-exposes the list), that left the
author without the full picture or a place to look.

Now the message names every concrete class participating in the growing cycle,
each tagged with the source file it is defined in, while filtering out the
interface and abstract-base supertypes that are merely specialized alongside the
growing classes (they do not construct the deeper values). The deepest worked
example and the actionable guidance are kept.

The message is pinned exhaustively by a dedicated test (families, files,
excluded supertypes, the deepest example, and each guidance chunk via
boundary-spanning assertions). The diagnostic-only helper arithmetic that is
equivalent under any diverging input (depth seeds, tie-breaks, redundant
collection paths, defensive name-normalization) is annotated rather than chased.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bstrings

The tower diagnostic tests checked the message with a scatter of
assertStringContainsString calls. Replace them with one whole-string assertSame
per fixture: normalize only the two genuinely-volatile parts — the absolute
source path (to <SRC>) and the depth-dependent worked example (dropped, and its
nesting pinned separately) — and assert the rest exactly. This pins the precise
wording, the family order, and the absence of any extra family in a single
assertion, and lets the redundant exhaustive substring test be removed. Diff
mutation coverage is unchanged (still 100% on the diff).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e load fatal

The subtype-grouping load-fatal test asserted only that the message contained
"must be compatible with". The generated class name is a pure function of the
type arguments (a sha256 of their canonical form), so it is deterministic and
implied by the instantiation — not opaque. Compute the ImmutableList<Book> and
ImmutableList<Media> FQNs via the production hasher and assert both appear, so
the test pins that it is precisely that covariant value-list override which
fatals, rather than any incompatibility. Self-maintaining: the FQNs are derived,
not hard-coded, so a hash-length change keeps the test in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a comment at the partial assertions explaining the choice: a PHP class-link
fatal carries run-/environment-dependent scaffolding (absolute temp paths, a
Call Stack with timings, memory figures, and include paths) that cannot be
matched as a whole string the way the tower-message tests are; only the
deterministic parts (the failure phrase and the offending specialization FQNs)
are pinned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a caveats entry for library authors hitting the list <-> map derivation
tower (a grouping derivation whose return type re-wraps the receiver's family,
then driving the growth by iterating the grouped buckets through the map's
generic views). Explains why it is a by-design boundary (monomorphization has no
fixed point for a body that constructs a strictly-deeper instantiation; the
depth cap terminates it with a localized diagnostic; an erased seam is deferred)
and the actionable cure: the group-then-read shape compiles as-is, iterate
buckets past a non-generic seam, or split the derivation; and type a grouped
value as the interface so a subtype-element upcast stays load-compatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…er generation

The divergence-diagnostic deliverables were authored against a reachability-
bounded discovery pass; on the eager pass they need three corrections, none of
which touch the diagnostic itself:

- Under eager structural discovery a result map's family-re-exposing views are
  walked even when never called, so a list-to-map derivation towers the moment
  the result is instantiated (not only when a view is invoked). Correct the
  caveat's framing (the member form does not "compile then abort on a view
  call") and its workaround (give the grouped result a view-less type, host the
  derivation on a non-variant helper, or use a non-generic seam).
- Replace the faithful multi-type fixtures with a minimal two-template cycle
  (Lst::toMap(): Mp<int, Lst<E>> + Mp::values(): Lst<V>). Eager over-generates
  the faithful surface (breadth explosion before the depth cap), which made the
  tests too heavy for the standard suite; the minimal cycle reaches the cap in
  ~0.06s / 24MB and still pins the multi-class cycle naming.
- Drop the subtype-element load-fatal characterization: under eager generation
  that shape towers first, so the covariant-override class-load incompatibility
  is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…neric diamond on upcast

A covariant upcast whose element type is itself a covariant generic forms a
diamond. With `Tuple<+A, +B>` over `Book <: Product`, `Tuple<Book,Book>` is a
subtype of two incomparable middles (`Tuple<Book,Product>`, `Tuple<Product,Book>`)
that both widen to `Tuple<Product,Product>`. So `List<Tuple<Book,Book>>` is, by
per-argument covariance, an instance of `Collection` at several supertype
arguments at once, each declaring its own distinctly-mangled erased member
(`contains_<…>`). The erased body is carried by class inheritance, and PHP
single inheritance can walk only one path of the diamond — leaving the
incomparable sibling's abstract member unimplemented, so the concrete spec was
emitted abstract-incomplete and fataled at class load ("contains N abstract
methods").

The fixpoint closer schedules-and-inherits the one member the chain threads; it
cannot see, at discovery time, which single path the variance edges will pick.
Add a post-edge pass that runs after the inheritance chain is final (after the
variance-edge emitter and the type rewrite): for each concrete spec, for each
covariant-upcast obligation its interface ancestry exposes that the final class
chain does NOT already provide, emit the member directly onto the spec. Because
it operates on the final chain it fills exactly the gaps inheritance left,
regardless of discovery order. A member direct emission cannot ground (a
return-position enclosing parameter, a trait-only body) still raises
`xphp.unschedulable_covariant_upcast` — a loud compile error, never emitted code
that fatals at load.

The mangled-name derivation is unified into one helper shared by emission and
the chain-provides check, so they cannot compute different names (a divergence
would silently skip a real gap). A phantom registry instantiation never
materialized as a class is skipped, not failed — it carries no load-time
obligation.

Covered by two committed fixtures: a minimal four-combo diamond, and a
multi-path closure where two covariant interfaces and two concrete classes
converge on one element diamond (the order-robustness case the defect originally
needed a large closure to surface). Both compile, load, and run end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic bound naming a scalar — `class Box<T : int|string>` — parsed, but its
scalar operands were namespace-qualified when the bound tree was built: `int`
became `<current-namespace>\int`. So the bound check compared a supplied scalar
against a phantom class union (`App\int | App\string`) and rejected every valid
scalar type argument with `"string" does not satisfy "App\int | App\string"`.

The bound leaf was the one type position that resolved its name through
`resolveNameOnly` (no scalar handling) instead of the scalar-aware path every
other position uses (`resolveTypeRef`). Add the scalar branch to the leaf
resolver: a non-fully-qualified name that is a reserved scalar/builtin keyword is
left unqualified and flagged scalar, so the bound check compares
scalar-against-scalar.

The branch matches a new `RESERVED_SCALAR_TYPES` list, not the broader
`SCALAR_TYPES`: the gettype-style aliases `integer`, `boolean`, `double` are legal
PHP class names, so a bound `<T : Double>` (where `Double` is a real class) must
still resolve to the class — matching the full list would false-reject that valid
class bound. Names are lowercased first, so `<T : Int|String>` is recognized
case-insensitively like a PHP type hint.

`<T : int|string>` now accepts `::<int>` / `::<string>`, `<T : int|float>` accepts
`::<float>`, a single `<T : string>` rejects `::<int>`, and a class argument is
still rejected with the bound rendered unqualified (`"App\Thing" does not satisfy
"int | string"`). Class bounds, intersections, F-bounds, and enclosing-type-param
bounds are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…very type position

The reserved-keyword list used to tell a builtin type apart from a class name
conflated the reserved PHP scalar keywords with the legacy gettype-style aliases
`integer`, `boolean`, `double`. Those three are not reserved words — they are
legal class names. So a class named `Double` (or `Integer`/`Boolean`) used as a
generic type argument (`new Box::<Double>(...)`) or as a member/signature type was
misclassified as the scalar `double`: the specialization was mangled on the scalar
key and the emitted type hint was the bare alias `double`, which PHP reads as a
non-existent class `\double` — a TypeError at runtime.

Correct the list at the source: keep only the genuinely reserved type keywords, so
every classifier site (type-argument and signature resolution, the FQN-rewrite
tagger, the undeclared-type check, the generic-bound leaf, and the subtype scalar
short-circuit) treats `integer`/`boolean`/`double` as class references. A class
that aliases a scalar now resolves to its real FQN (`\App\Double`) in argument and
signature positions, matching the bound position. The now-redundant reserved-subset
list added for the bound fix is folded back into the one corrected list.

Genuine scalars (`int`, `string`, `bool`, `float`, and case variants like `Int`)
are unchanged in every position. An undeclared `Double` member is now reported as
`xphp.undeclared_type` rather than silently absorbed as a scalar. (An undeclared
type *argument* remains a pre-existing silent phantom-FQN — unchanged here.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant